React 两大特色之React diff

React 两大特色之React diff

React 的两大特色:React diff 和 Virtual Dom。

前言

React diff,是 Virtual DOM和渲染的性能保证,高效的diff算法极大地保障了React的性能。使我们的页面的构建效率提到了极大的提升。

传统的diff算法

传统diff算法通过循环递归对节点进行依次对比,效率低下,算法复杂度达到 O(n^3),其中 n 是树中节点的总数。具体是怎么算出来的,可以查看知乎上的一个回答。

react的diff 从O(n^3)到 O(n) ,请问 O(n^3) 和O(n) 是怎么算出来?

懒得点链接看下:

传统Diff算法需要找到两个树的最小更新方式,所以需要[两两]对比每个叶子节点是否相同,对比就需要O(n^2)次了,再加上更新(移动、创建、删除)时需要遍历一次,所以是O(n^3)。

查找不同就需要O(n^2),找到差异后还要计算最小转换方式,最终结果为O(n^3)

1
2
3
4
5
6
7
Prev                  Last
A A
/ \ / \
/ \ / \
B D ====> D B
/ \
C C

就上面两树的变化而言,若要达到最小更新,首先要对比每个节点是否相同,也就是:

1
2
3
4
5
6
PA->LA
PA->LB
PA->LC
PA->LD
PB->LA
...

React的Diff算法完全不同,简单到有些粗暴,过程如下。

1
2
3
4
5
6
# 按叶子节点位置比较
[0,0] : PA->LA # 相同,不理会
[0.0, 0.0]: PB->LD # 不同,删除PB,添加LD
[0.1, 0.1]: PD->LB # 不同,更新
[0.1.0, 0.1.0]: PC->Null # Last树没有该节点,所以删除PC即可
[0.1.2, 0.1.2]: Null->LC # Prev树没有该节点,所以添加C到该位置

标准的O(n),所有的节点只遍历一次。

O(n^3)到O(n)的提升有多大,通过一张图来看一下

image.png

React的diff策略

  1. 策略一:忽略Web UI中DOM节点跨层级移动;(跨层级不进行比较)

  2. 策略二:拥有相同类型的两个组件产生的DOM结构也是相似的,不同类型的两个组件产生的DOM结构则不近相同(不同类不进行比较,同级节点做diff)

  3. 策略三:对于同一层级的一组子节点,通过分配唯一id进行区分(同类同级通过key比较)

上面的好像不太通俗易懂

  1. 一:只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会复用它。
  2. 二:两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 三:开发者可以通过 key属性 来暗示哪些子元素在不同的渲染下能保持稳定。

在Web UI的场景下,基于以上三个点,React对tree diff、component diff、element diff进行优化,将普适diff的复杂度降低到一个数量级,保证了整体UI界面的构建性能!

Tree diff


针对策略一,React对树的算法进行了优化,对树进行分层比较,两棵树只会对同一层次的节点进行比较。

既然DOM节点跨层级的移动操作少到可以忽略不计,针对这一现象,React只会对相同层级的DOM节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在时,则该节点及其子节点会被完全删除掉,不会进行下一步的比较。这样就只需要对树进行一次遍历,就能完成整个DOM树的比较。

image.png

前提是WEB ui 中的DOM节点跨层级的移动操作特别少,但并没有否定DOM节点跨层级的操作的存在,那么当遇到这种操作时,React的处理方式

example; A(包括其子节点) –> D

image.png

由于React只会简单的考虑同层级节点的位置变换,而对于不同层级的节点,只会创建和删除操作。当根节点发现子节点的A消失了,就会直接销毁A,当D发现多了一个子节点A,则会创建新的A(包括子节点)作为其子节点。此时,diff的执行情况是;

​ CreateA —> CreateB —> CreateC —> DeleteA

所以,由此可以看出,当出现节点跨层级移动,并不会出现想象中的直接把整个A节点移动到D节点下的移动操作,而是以A节点的整个树会被重新创建。so,这是一种影响React性能的操作,因此官方建议不要进行DOM节点跨层级的操作。

在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏或显示节点,而不是真正地移除或添加 DOM 节点。

图整个处理过程:

image.png

Component diff


React 是基于组件构建应用的,对于组件间的比较所采取的策略也是简洁、高效的。

  1. 如果是同一类型的组件,按照原策略继续比较 Virtual DOM 树即可。

  2. 如果不是,则将该组件判定为dirty component,从而替换整个组件下的所有子节点

  3. 对于同一类型的组件,有可能其Virtual DOM没有任何变化,如果能够确切知道这点,那么可以节省大量的diff运算时间,因此,React允许用户通过ShouldComponentUpdate()来判断该组件是否需要进行diff算法,但如果调用了forceUpdate方法,ShouldComponentUpdate会失效

    example; D –> G

image.png

内部流程

image.png

当组件D变为组件G时,即使这两个组件的结构相似,一旦React判断D和G是不同类型的组件,就不会比较二者的结构,而是直接删除组件D,重新创建组件G及其子节点。虽然当两个组件时不同类型但结构相似时,diff会影响性能,但入React官方所说,不同类型的组件很少存在相似DOM树的情况,因此这种极端因素很难在实际开发过程中造成较大的影响。

Element diff

当节点处于同于同一层级时候,diff提供了3种节点操作,分别是插入、移动、删除

  • 插入:新的组件类型不在旧集合里,即全新的节点,需要对新节点执行插入操作
  • 移动:旧集合里有新组件类型,且element是可更新的类型,这种情况下 prevChild=nextChild ,就需要做移动操作,可以复用以前的 DOM 节点。
  • 删除:旧组件类型,在新集合里也有,但对应的element不同则不能直接复用和更新,需要执行删除操作,或者旧组件不在新集合里,也需要执行删除操作。

旧集合中包含节点A、B、C和D,

更新后的新集合中包含节点B、A、D和C,

此时新旧集合进行diff差异化对比,发现B!=A,则创建并插入B至新集合,删除旧集合A;以此类推,创建并插入A、D和C,删除B、C和D。


我们发现这些都是相同的节点,仅仅是位置发生了变化,但却需要进行繁杂低效的删除、创建操作,其实只要对这些节点进行位置移动即可。React针对这一现象提出了一种优化策略:让开发者对同一层级的同组子节点,添加唯一key进行区分。虽然这只是小小的改动,性能上却发生了翻天覆地的变化,再来看一下应用了这个策略之后,diff是如何处理的

example:

image.png

通过key可以准确的发现新旧集合中的节点都是相同的节点,因此无需进行节点删除和创建,只需要将旧集合中的位置进行移动,更新为新集合中节点的位置名词是的React给出的diff结果为:B、D不做任何操作,A、C进行移动操作就行。

具体的流程看表格:

index 节点 oldIndex lastIndex 操作
0 B 1 0 oldIndex(1)>lastIndex(0),lastIndex=oldIndex,lastIndex变为1
1 A 0 1 oldIndex(0)<lastIndex(1),节点A移动至index(1)的位置
2 D 3 1 oldIndex(3)>lastIndex(1),lastIndex=oldIndex,lastIndex变为3
3 C 2 3 oldIndex(2)<lastIndex(3),节点C移动至index(3)的位置
  • index: 新集合的遍历下标。
  • oldIndex:当前节点在老集合中的下标。
  • lastIndex:在新集合访问过的节点中,其在老集合的最大下标值(在老集合中最右的位置, 即最大的位置)。

操作一栏中只比较oldIndex和lastIndex:

  • 当oldIndex>lastIndex时,将oldIndex的值赋值给lastIndex

  • 当oldIndex=lastIndex时,不操作

  • 当oldIndex<lastIndex时,将当前节点移动到index的位置,

    新版本React,应该是从v16起, placeChild(newFiber, lastPlacedIndex, newIndex), lastPlacedIndex就是当前的新fiber数组中已经遍历过的fiber中在上一次更新时的index中最大的那个,就是上面的lastIndex

老版本React源码中的enqueueMove(this, child._mountIndex, toIndex),其中 toIndex 其实就是 nextIndex

image-20230506112857647


上面例子是新旧集合中的节点都是相同的节点的情况下,那如果新集合中有新加入的节点且旧集合存在 需要删除的节点,那么 diff 又是如何对比运作的

ABCD –> BECA

index 节点 oldIndex lastIndex 操作
0 B 1 0 oldIndex(1)>lastIndex(0),lastIndex=oldIndex,lastIndex变为1
1 E - 1 oldIndex不存在,添加节点E至index(1)的位置
2 C 2 1 oldIndex(2)>lastIndex(1),lastIndex=oldIndex,lastIndex变为2
3 A 0 2 oldIndex(0)<lastIndex(2),节点A移动至index(3)的位置

PS:最后还需要对旧集合进行循环遍历,找出新集合中没有的节点,此时发现存在这样的节点D,因此删除节点D,到此 diff 操作全部完成。

同样操作一栏中只比较oldIndex和lastIndex,但是oldIndex可能有不存在的情况:

  • oldIndex存在
    1. 当oldIndex>lastIndex时,将oldIndex的值赋值给lastIndex
    2. 当oldIndex=lastIndex时,不操作
    3. 当oldIndex<lastIndex时,将当前节点移动到index的位置
  • oldIndex不存在

    1. 新增当前节点至index的位置

    image.png


当然这种diff并非完美无缺的,我们来看这么一种情况:

image.png

实际我们只需对D执行移动操作,然而由于D在旧集合中的位置是最大的,导致其他节点的oldIndex < lastIndex,造成D没有执行移动操作,而是A、B、C全部移动到D节点后面的现象。针对这种情况,官方建议:

在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作。当节点数量过大或更新操作过于频繁时,这在一定程度上会影响React的渲染性能

由于key的存在,react可以准确地判断出该节点在新集合中是否存在,这极大地提高了diff效率。我们在开发过中进行列表渲染的时候,若没有加key,react会抛出警告要求开发者加上key,就是为了提高diff效率。

但是加了key一定要比没加key的性能更高吗?我们再来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
现在有一集合[1,2,3,4,5],渲染成如下的样子:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
---------------
现在我们将这个集合的顺序打乱变成[1,3,2,5,4]。
1.加key
<div key='1'>1</div> <div key='1'>1</div>
<div key='2'>2</div> <div key='3'>3</div>
<div key='3'>3</div> ========> <div key='2'>2</div>
<div key='4'>4</div> <div key='5'>5</div>
<div key='5'>5</div> <div key='4'>4</div>
操作:节点2移动至下标为2的位置,节点4移动至下标为4的位置。

2.不加key
<div>1</div> <div>1</div>
<div>2</div> <div>3</div>
<div>3</div> ========> <div>2</div>
<div>4</div> <div>5</div>
<div>5</div> <div>4</div>
操作:修改第1个到第5个节点的innerText
---------------
如果我们对这个集合进行增删的操作改成[1,3,2,5,6]。
1.加key
<div key='1'>1</div> <div key='1'>1</div>
<div key='2'>2</div> <div key='3'>3</div>
<div key='3'>3</div> ========> <div key='2'>2</div>
<div key='4'>4</div> <div key='5'>5</div>
<div key='5'>5</div> <div key='6'>6</div>
操作:节点2移动至下标为2的位置,新增节点6至下标为4的位置,删除节点4。

2.不加key
<div>1</div> <div>1</div>
<div>2</div> <div>3</div>
<div>3</div> ========> <div>2</div>
<div>4</div> <div>5</div>
<div>5</div> <div>6</div>
操作:修改第1个到第5个节点的innerText
---------------
通过上面这两个例子我们发现:
由于dom节点的移动操作开销是比较昂贵的,没有key的情况下要比有key的性能更好。

通过上面的例子我们发现,虽然加了key提高了diff效率,但是未必一定提升了页面的性能。因此我们要注意这么一点:

对于简单列表页渲染来说,不加key要比加了key的性能更好

根据上面的情况,最后我们总结一下key的作用:

  • 准确判断出当前节点是否在旧集合中
  • 极大地减少遍历次数

渲染

合并操作

当调用component的setstate时,React会将其标记为dirty。到每个时间循环结束,React检查所有标记dirty的component重新绘制

这里的“合并操作”是说,在一个事件循环中,DOM只会被更新一次,这个特性是构建高性能应用的关键。

子树渲染

调用setState方法时,component会重新构建包括子树子节点的virtual DOM 。如果你在根节点调用setState,整个React应用都会被重新渲染。所有的component,即便没有更新,都会调用他们的render方法,这个听起来很可怕,性能实际上很低,但实际上我们不会触及真实的DOM,运行起来就没问题。

在写react代码时,每当数据有更新,你都不会算调用根节点的setState。你会在需要接收对应的component上调用,或者在上面的几个component。你很少要一直到根节点。就是说页面更新只出现在用户交互的部分。

image-20230506113003124

选择子树渲染

去掉一些子树的重新渲染.

1
boolean shouldComponentUpdate(object nextProps, object nextState)

根据component的前一个和下一个props/state, 你可以告诉React这个component没有更新,也不需要重绘

注意, 这个函数每次都会被调用, 所以你要确保运行起来花的时间更少,
比 React 的做法时间少, 还有比计算 component 需要的时间少,
即便重新绘制并不是必要的.

Ps: 不过有可能你的方法里写的比React花费的时间更多

image-20230506113047182

总结

  • React的高效得益于VIrtual DOM + React diff的体系。diff算法并非React独创,react只是在传统的diff算法中做了优化。但因为优化,将diff算法的时间复杂度一下从O(n^3) 降到了 O(n)。

  • 开发组件时,保持稳定的dom结构会有助于性能的提升。

  • 开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作。

  • key的存在是为了提升diff效率,但未必一定就可以提升性能,记住简单列表渲染情况下,不加key要比加key的性能更好。(不过可能因为React console的提醒,大家都会加上,个人觉得这点性能问题不大)。

  • 我们可以根据diff的特点,在具体场景中取长补短,规避一些算法上面的短板也是有利于提升应用整体的性能。

之后可以简单实现下diff

参考

浅谈React中的diff

react的diff 从O(n^3)到 O(n) ,请问 O(n^3) 和O(n) 是怎么算出来?

React 源码剖析系列 - 不可思议的 react diff

React diff原理探究以及应用实践

React Fiber 原理介绍

感谢你的打赏哦!